Table of Contents
들어가는 글
최근에 API-Gateway 에서 인증 미들웨어를 리팩토링하면서 이상적인 미들웨어는 어떤 모습인지 고민했다. 시니어 개발자분의 코드 리뷰도 받으면서 바람직한 미들웨어의 모습으로 리팩토링 할 수 있었고 이 과정에서 미들웨어를 어떻게 작성해야 하는지 배울 수 있었다. 예시로 작성된 As-Is, To-Be 코드를 비교하면서 미들웨어를 미들웨어 답게 사용하는 방식을 알아보자.
🌐 미들웨어(Middleware)란 무엇인가?
미들웨어(Middleware)
는 웹 애플리케이션에서 클라이언트의 요청(Request)과 서버의 응답(Response) 사이에 위치한 중간 처리 계층으로 요청이 들어올 때 이를 가로채 무언가를 처리한 뒤, 다음 단계로 넘기는 함수다.
미들웨어는 하나의 요청을 처리하기 전에, 혹은 처리가 끝난 후에 다양한 작업을 수행할 수 있도록 지원한다.
- 인증(Authentication)
- 권한 검사(Authorization)
- 로깅(Logging)
- 트레이싱(Tracing)
- CORS 처리
- 요청/응답 가공: 헤더 추가, 파라미터 수정 등
이처럼 미들웨어는 요청과 응답 흐름을 제어하며, 코드의 재사용성과 관심사 분리를 도와주는 핵심 컴포넌트로 동작한다. 미들웨어가 어떤 상황에서 사용되는지는 간단히 알아봤다. 그럼 이 미들웨어는 어떻게 구성하는게 바람직할까? 우선 미들웨어를 구현하는 방식은 Go 언어를 기준으로 크게 함수형(functional)과 구조체 기반(object-oriented)으로 나눌 수 있다. 두 방식 모두 장단점이 있으나 함수형 방식을 택하는 것이 일반적이다. 두 스타일의 코드를 살펴보면서 왜 함수형 방식의 미들웨어를 더 선호하는지 살펴보자. AS-IS 코드는 구조체 기반(OOP-Style)으로 작성됐으며 TO-BE 코드는 함수형 방식(FP-Style)으로 작성되었다.
코드를 보기 전에 실무에서 미들웨어를 구성할 때 고려할 기준을 알아보자. 실제 프로젝트에서는 단순히 코드 스타일보다 더 중요한 비즈니스 요구사항과 운영 환경에 따라 미들웨어 설계를 결정한다.
1. 상태(State)의 유무
- 요청마다 완전히 독립적으로 동작하면 → 함수형
- 내부에 캐시, 설정, 통계 등을 유지해야 한다면 → 구조체형
2. 의존성 주입
- 단순 의존성 몇 개라면 함수의 인자 전달로 충분
- 여러 서비스나 리소스를 주입해야 한다면 구조체에 담아 관리
3. 테스트 용이성
- 함수형은 테스트하기 쉬움 (단순 함수 호출로 끝)
- 구조체 기반은 mocking setup이 필요하므로 비교적 복잡
4. 재사용성 및 유연성
- 여러 서비스에서 동일한 미들웨어를 사용할 가능성이 크다면 → 함수형
- 미들웨어 내 동작을 유동적으로 바꿔야 한다면 → 구조체형
5. 프레임워크와의 궁합
Echo
,Gin
,Fiber
등 Go의 웹 프레임워크들은 대부분 함수형 미들웨어를 기본으로 지원
🧩 코드로 알아보는 미들웨어
AS-IS (구조체 기반 미들웨어)
아래는 인증을 담당하는 미들웨어 코드다. authenticate 변수에 인증 로직을 담당하는 미들웨어를 주입하여 Authenticate() 메서드를 호출하여 인증을 처리한다. 미들웨어가 무엇인지 정확히 알기 전까지는 겉보기에 문제가 없는 코드라 생각했다. 하지만 코드리뷰에선 미들웨어를 미들웨어 답게 사용하는 것이 좋다는 피드백을 받았다. 그 때 까지만 해도 미들웨어가 어떤 역할을 하는지 명확히 몰랐기에 무엇을 고쳐야 하는지 모호했다.
func Router(e *echo.Echo, endpoint string, authClientConn *grpc.ClientConn) { ... authenticate := middlewares.NewAuthMiddleware(nonTokenAuth, headerTokenAuth, anonymousAuth) buildMiddlewares := func() []echo.MiddlewareFunc { return []echo.MiddlewareFunc{ ... authenticate.Authenticate(), ... } } }
type AuthMiddleware struct { authenticators []Authenticator } func NewAuthMiddleware(authenticators ...Authenticator) *AuthMiddleware { return &AuthMiddleware{ authenticators: authenticators, } } // Authenticate returns a middleware function that handles authentication func (m *AuthMiddleware) Authenticate() func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ec echo.Context) error { req := ec.Request() ctx := req.Context() ... return echo.NewHTTPError(http.StatusUnauthorized, "Authentication failed") } } }
장점
- ✅ 구성 요소 분리 — 여러 메서드로 미들웨어 기능을 분할 가능
- ✅ 상태 보존 가능 — 캐시, 통계, 리소스 연결 등을 멤버로 가질 수 있음
- ✅ 확장성 용이 — 인터페이스 구현 및 조합에 유리
단점
- ❌ 사용법이 직관적이지 않을 수 있음 (.Authenticate() 호출을 잊기 쉬움)
- ❌ 테스트 시 setup이 많아질 수 있음
- ❌ 단순한 로직에 과한 구조를 적용하면 오히려 복잡도 증가
TO-BE (함수형 미들웨어)
As-Is 코드와 비교하면 어떤 차이가 있는지 살펴보자.
기존 코드에서는 AuthMiddleware 구조체를 정의하고 NewAuthMiddleware (생성자) 함수로 인스턴스를 생성, Authenticate() 메서드를 호출하여 인증 기능을 추가했다. 인증 이란 중간 처리 작업을 위해 하나의 구조체와 두개의 메서드를 추가했다.
반면 To-Be 코드는 꽤나 간결해진 것을 볼 수 있다. 애초에 AuthMiddleware 구조체도, 생성자 함수도 정의하지 않았다. 오직 인증 기능을 수행하기 위한 함수만 정의했다.
코드가 간결해졌고 buildMiddlewares
함수에서도 별도로 authenticate.Authenticate()
처럼 메서드를 호출하지 않아도 된다. 그저 authenticate 미들웨어를 넘길 뿐이다.
왜 To-Be 처럼 작성하는게 더 나은 방식일까? 이는 더 나은 방식이라기 보다 미들웨어을 미들웨어 답게 사용하는 방식이라 표현하는 것이 맞겠다.
func Router(e *echo.Echo, endpoint string, authClientConn *grpc.ClientConn) { ... authenticate := middlewares.NewAuthMiddlewareV2(nonTokenAuth, headerTokenAuth, anonymousAuth) buildMiddlewares := func() []echo.MiddlewareFunc { return []echo.MiddlewareFunc{ authenticate, // 이전과 달리 단순히 미들웨어를 넘기면 된다. } } }
func NewAuthMiddlewareV2(authenticators ...Authenticator) func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ec echo.Context) error { req := ec.Request() ctx := req.Context() ... return echo.NewHTTPError(http.StatusUnauthorized, "Authentication failed") } } }
장점
- ✅ 간결하고 직관적 — 미들웨어는 결국 함수라는 본질에 충실
- ✅ 재사용 및 테스트 용이 — 의존성만 주입하면 어떤 컨텍스트에서도 사용 가능
- ✅ 프레임워크 친화적 — Echo, Gin 등 대부분의 프레임워크와 쉽게 통합
단점
- ❌ 의존성이 많아질 경우 함수 시그니처가 길어질 수 있음
- ❌ 상태를 저장해야 할 경우 별도의 구조 필요
💭 왜 이렇게 써야 하는지?
각각 방식의 장단점이 있지만 미들웨어는 결국 함수형 방식으로 작성하길 권장한다. 왜 미들웨어는 To-Be 처럼 작성하는게 나을까? 미들웨어는 본질적으로 다음의 특성을 지닌다.
- Function Chain, 각각의 미들웨어는 다음(next) 함수를 감싸고 필요에 따라 실행을 중단하거나 계속 이어갈 수 있다.
- 이상적인 미들웨어는 상태를 지니지 않고(stateless), 조합 가능하며(composable), 선언적이고(declarative) 구조를 가져야 한다.
Stateless
좋은 미들웨어는 상태를 저장하지 않아야 한다. 요청마다 독립적으로 동작하고 내부에 캐시나 전역변수 같은 상태를 유지하지 않아야 한다. 또한 필요한 의존성이나 설정은 외부에서 함수 인자로 주입받는 것이 일반적이다.
Composable & Declarative
미들웨어는 서로 다른 여러 미들웨어를 체인처럼 연결해서 사용할 수 있어야 한다. 가령 아래 코드처럼 서로 다른 역할을 하는 미들웨어를 조합하여 사용한다.
e.Use(loggingMiddleware) // logging e.Use(authenticationMiddleware) // auth e.Use(rateLimitingMiddleware) // rate-limit
이렇게 조합하면 요청은 로깅 -> 인증 -> rate-limit 순서로 처리할 수 있는데, 이렇듯 명확한 단위의 미들웨어를 조립하여 전체 흐름을 구성할 수 있다. 뿐만 아니라 위 코드를 읽는 사람은 요청이 어떻게 처리되는지 흐름을 명확하게 이해할 수 있다. 복잡한 설정이나 숨겨진 내부 동작없이 이 미들웨어가 어떤 역할을 하는지 코드만 봐도 알 수 있어야 한다.
맺는 말
부끄럽지만 사실 미들웨어가 정확히 어떤 것인지도 모른책 코드를 작성했다. 그저 기존에 작성된 코드를 보면서 유사하게 작성했는데, 그러다 보니 바람직한 미들웨어가 무엇인지 생각할 겨를도 없었다. 이번 리팩토링으로 미들웨어가 갖춰야할 모습(함수형 구조)과 특성(stateless, composable, declarative)을 배웠다. 조합 가능한 미들웨어 구조를 잡았기에 추후에 새로운 인증 미들웨어가 추가되더라도 기존 코드베이스를 크게 수정하지 않고 새로운 미들웨어만 조합하는 방식으로 확장 가능한 구조를 갖추게 됐다.